15 — 阴影

Three.js Journey · Bruno Simon · 难度:简单

15 — 阴影 课程海报

介绍

既然我们有了灯光,我们就想要阴影。物体的背面确实处于黑暗中,这叫做自身阴影(core shadow)。我们缺少的是投影阴影(drop shadow),即物体在其他物体上投射出阴影的效果。

阴影对于实时 3D 渲染来说一直是个挑战,开发者们必须想办法在合理的帧率下显示逼真的阴影。

实现方式有很多种,Three.js 有一个内置的解决方案。请注意,这个方案虽然方便,但远非完美。

它是怎么工作的

我们不会详述阴影在内部是如何工作的,但我们会尽量理解基础知识。

当你做一次渲染时,Three.js 会先为每个应该投射阴影的灯光做一次渲染。这些渲染会模拟灯光所看到的东西,就像它是一台相机一样。在这些灯光渲染中,MeshDepthMaterial 会替换所有 mesh 的材质。

结果被存储为纹理,叫做阴影贴图(shadow map)。

你不会直接看到这些阴影贴图,但它们会被用在每一个应该接收阴影的材质上,并投射到几何体上。

这里有一个很好的例子,展示了定向光和聚光灯能看到什么:https://threejs.org/examples/webgl_shadowmap_viewer.html

设置

我们的 starter 由平面上的一个简单球体、一盏定向光和一盏环境光组成。

你可以用 lil-gui 控制这些灯光以及材质的 metalness 和 roughness。

如何激活阴影

首先,我们需要在 renderer 上激活阴影贴图:

const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.shadowMap.enabled = true

然后,我们需要遍历场景中的每个物体,决定这个物体能否用 castShadow 属性投射阴影,以及能否用 receiveShadow 属性接收阴影。

尽量只在尽可能少的物体上激活这些属性:

sphere.castShadow = true

// ...
plane.receiveShadow = true

最后,用 castShadow 属性在灯光上激活阴影。

只有下面这些类型的灯光支持阴影:

而且同样,尽量只在尽可能少的灯光上激活阴影:

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
directionalLight.castShadow = true

你应该会在平面上得到球体的阴影。

遗憾的是,这个阴影看起来很糟糕。让我们试着改进它。

阴影贴图优化

渲染尺寸

正如我们在课程开头说的,Three.js 会为每个灯光做名为阴影贴图的渲染。你可以使用灯光上的 shadow 属性访问这个阴影贴图(以及许多其他东西):

directionalLight.castShadow = true
console.log(directionalLight.shadow)

和我们的渲染一样,我们需要指定一个尺寸。默认情况下,出于性能考虑,阴影贴图的尺寸只有 512x512。我们可以改进它,但记住你需要一个 2 的幂的值,供 mipmapping 使用:

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.width = 1024
directionalLight.shadow.mapSize.height = 1024

阴影应该已经看起来好一些了。

近端和远端

Three.js 使用相机来进行阴影贴图渲染。这些相机拥有和我们已经用过的相机相同的属性。这意味着我们必须定义 near 和 far。它不会真正提升阴影的质量,但可能修复一些 bug,比如你看不到阴影,或者阴影突然看起来被裁剪了。

为了帮助调试相机并预览 near 和 far,我们可以使用一个 CameraHelper,其使用的相机位于 directionalLight.shadow.camera 属性里:

const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)

现在你可以直观地看到相机的 near 和 far。尽量找一个适合场景的值,并且务必在创建 helper 之前完成修改,否则那个 helper 不会显示正确的 near 和 far 值:

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
// ...
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 6
// ...

const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)

取景范围

通过我们刚添加的相机 helper,我们可以看到相机的取景范围太大了。

因为我们使用的是 DirectionalLight,Three.js 使用的是一个 OrthographicCamera。如果你还记得相机的课程,我们可以用 top、right、bottom 和 left 属性控制相机每一侧能看到多远。让我们缩小这些值:

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
// ...
directionalLight.shadow.camera.top = 2
directionalLight.shadow.camera.right = 2
directionalLight.shadow.camera.bottom = - 2
directionalLight.shadow.camera.left = - 2

值越小,阴影越精确。但如果太小,阴影会被裁剪掉。

完成后你可以隐藏相机 helper:

const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
directionalLightCameraHelper.visible = false

模糊

你可以用 radius 属性控制阴影的模糊程度:

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
// ...
directionalLight.shadow.radius = 10

这个技术不使用相机与物体之间距离。它只是一个通用且廉价(开销小)的模糊。

阴影贴图算法

不同类型的算法可以应用于阴影贴图:

要改变它,请更新 renderer.shadowMap.type 属性。默认是 THREE.PCFShadowMap,但你可以为了更好的质量使用 THREE.PCFSoftShadowMap。

const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap

请注意,radius 属性在 THREE.PCFSoftShadowMap 下不起作用。你必须做出取舍。

SpotLight

让我们像在灯光课程中做的那样添加一个 SpotLight,并把 castShadow 属性设为 true。别忘了把 target 属性添加到 scene。

我们还会添加一个相机 helper:

// Spot light
const spotLight = new THREE.SpotLight(0xffffff, 3.6, 10, Math.PI * 0.3)
spotLight.castShadow = true
spotLight.position.set(0, 2, 2)
scene.add(spotLight)
scene.add(spotLight.target)

const spotLightCameraHelper = new THREE.CameraHelper(spotLight.shadow.camera)
scene.add(spotLightCameraHelper)

如果场景太亮,你可以降低其他灯光的强度:

const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)

// ...

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.3)

如你所见,阴影无法很好地融合在一起。它们是各自独立处理的,而且不幸的是,对此没有太多办法。

就像定向光的阴影一样,我们可以提升阴影质量。让我们从分辨率入手。

修改 shadow.mapSize:

const spotLight = new THREE.SpotLight(0xffffff, 3.6, 10, Math.PI * 0.3)
spotLight.castShadow = true
spotLight.shadow.mapSize.width = 1024
spotLight.shadow.mapSize.height = 1024

因为我们现在使用 SpotLight,内部 Three.js 使用的是 PerspectiveCamera。我们不能修改 fov,因为它会被 SpotLight 的 angle 覆盖,但我们可以修改它的 near 和 far 属性:

const spotLight = new THREE.SpotLight(0xffffff, 3.6, 10, Math.PI * 0.3)
// ...
spotLight.shadow.camera.near = 1
spotLight.shadow.camera.far = 6

完成后你可以隐藏相机 helper:

const spotLightCameraHelper = new THREE.CameraHelper(spotLight.shadow.camera)
spotLightCameraHelper.visible = false

PointLight

让我们试试最后一个支持阴影的灯光,PointLight:

// Point light
const pointLight = new THREE.PointLight(0xffffff, 2.7)
pointLight.castShadow = true
pointLight.position.set(- 1, 1, 0)
scene.add(pointLight)

const pointLightCameraHelper = new THREE.CameraHelper(pointLight.shadow.camera)
scene.add(pointLightCameraHelper)

如果场景太亮,你可以降低其他灯光的强度:

const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)

// ...

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.1)

// ...

const spotLight = new THREE.SpotLight(0xffffff, 2.4, 10, Math.PI * 0.3)

如你所见,相机 helper 是一个 PerspectiveCamera(和 SpotLight 一样),但朝下。这是因为 Three.js 处理 PointLight 阴影贴图的方式如此。

因为点光向所有方向照明,Three.js 将不得不渲染全部 6 个方向,来创建一个立方体阴影贴图。你看到的这个相机 helper 是相机在 6 次渲染中最后一次的位置(也就是朝下的那次)。

做所有这些渲染可能产生性能问题。尽量避免有太多启用了阴影的 PointLight。

这里唯一能调整的属性是 mapSize、near 和 far:

const pointLight = new THREE.PointLight(0xffffff, 2.7)
pointLight.castShadow = true
pointLight.shadow.mapSize.width = 1024
pointLight.shadow.mapSize.height = 1024
pointLight.shadow.camera.near = 0.1
pointLight.shadow.camera.far = 5

完成后你可以隐藏相机 helper:

const pointLightCameraHelper = new THREE.CameraHelper(pointLight.shadow.camera)
pointLightCameraHelper.visible = false

烘焙阴影

如果场景简单,Three.js 的阴影会非常有用,但否则它可能会变得一团糟。

一个很好的替代方案是烘焙阴影(baked shadows)。我们在上一节课谈过烘焙光照,这完全是同一回事。阴影被整合到我们应用到材质上的纹理里。

与其注释掉所有与阴影相关的代码行,我们可以在 renderer 里直接停用它们:

const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.shadowMap.enabled = false

现在我们可以用经典的 TextureLoader 加载位于 /static/textures/bakedShadow.jpg 的阴影纹理。

在创建物体和灯光之前添加以下代码:

/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()
const bakedShadow = textureLoader.load('/textures/bakedShadow.jpg')

用作 map 和 matcap 的纹理本应采用 sRGB 编码,我们需要通过把它们的 colorSpace 设为 THREE.SRGBColorSpace 来告知 Three.js:

const bakedShadow = textureLoader.load('/textures/bakedShadow.jpg')
bakedShadow.colorSpace = THREE.SRGBColorSpace

最后,与其在平面上使用 MeshStandardMaterial,我们用把 bakedShadow 作为 map 的简单 MeshBasicMaterial:

const plane = new THREE.Mesh(
    new THREE.PlaneGeometry(5, 5),
    new THREE.MeshBasicMaterial({
        map: bakedShadow
    })
)

你应该会看到一个漂亮的、模糊的、逼真的假阴影。主要问题是它不是动态的,如果球体或灯光移动,阴影不会跟着动。

烘焙阴影的替代方案

一个不那么真实但更动态的解决方案,是在球体下方、略微高于平面的位置放一个更简单的阴影。

这个纹理是一个简单的光晕。白色部分可见,黑色部分不可见。

然后,我们让这个阴影跟着球体移动。

首先,让我们把之前的烘焙阴影移除,给平面换回 MeshStandardMaterial:

const plane = new THREE.Mesh(
    new THREE.PlaneGeometry(5, 5),
    material
)

然后,我们可以加载位于 /static/textures/simpleShadow.jpg 的基础阴影纹理。

const simpleShadow = textureLoader.load('/textures/simpleShadow.jpg')

我们可以用一个简单平面来创建阴影,把它旋转并放置在略高于地面的位置。材质必须是黑色,但要使用阴影纹理作为 alphaMap。别忘了把 transparent 改为 true,并把 mesh 添加到 scene:

const sphereShadow = new THREE.Mesh(
    new THREE.PlaneGeometry(1.5, 1.5),
    new THREE.MeshBasicMaterial({
        color: 0x000000,
        transparent: true,
        alphaMap: simpleShadow
    })
)
sphereShadow.rotation.x = - Math.PI * 0.5
sphereShadow.position.y = plane.position.y + 0.01

scene.add(sphere, sphereShadow, plane)

搞定了,一个不那么真实但性能极佳的阴影。

如果你要动画化球体,你可以相应地动画化阴影,并根据球体的高度改变它的不透明度:

const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update the sphere
    sphere.position.x = Math.cos(elapsedTime) * 1.5
    sphere.position.z = Math.sin(elapsedTime) * 1.5
    sphere.position.y = Math.abs(Math.sin(elapsedTime * 3))

    // Update the shadow
    sphereShadow.position.x = sphere.position.x
    sphereShadow.position.z = sphere.position.z
    sphereShadow.material.opacity = (1 - sphere.position.y) * 0.3

    // ...
}

tick()
lesson demo video preview

该用哪种技术

找到处理阴影的正确方案取决于你自己。这取决于项目、性能以及你掌握的技巧。你也可以组合使用它们。